One of the questions that arise from LiveView’s stateful model is what considerations are necessary when deploying a new version or when recovering from an error.
Automatic Reconnection
Whenever LiveView disconnects, it automatically attempts to reconnect to the server using exponential back-off:
Immediate retry
First reconnection attempt happens immediately.
Exponential back-off
Subsequent retries wait progressively longer (2s, 5s, etc.).
Load balancer redirect
During deployments, the next reconnection will be redirected to new servers.
This means that in most deployment scenarios, your users will experience a brief disconnection followed by automatic reconnection to the new version.
State Management During Deployments
Your LiveView may have state that will be lost during disconnections and deployments. The good news is that there are several practices you can follow that will not only help with deployments but improve your application overall.
1. Keep State in Query Parameters
For UI state like tabs, filters, or sorting, use URL parameters instead of server-side state.
Bad Approach:
# Using handle_event to manage tabs
def handle_event("switch_tab", %{"tab" => tab}, socket) do
{:noreply, assign(socket, current_tab: tab)}
end
Good Approach:
# Using patch navigation with URL parameters
<.link patch={~p"/dashboard?tab=settings"}>Settings</.link>
def handle_params(params, _uri, socket) do
tab = params["tab"] || "overview"
{:noreply, assign(socket, current_tab: tab)}
end
Benefits:
- State persists across disconnections
- URLs are shareable
- Better SEO and indexing
- Browser back/forward buttons work correctly
2. Store Relevant State in the Database
For important state that should persist across sessions and devices, use the database.
Example: Chat Application
def mount(%{"room_id" => room_id}, %{"user_id" => user_id}, socket) do
# Retrieve last read message from database
last_read = Messages.get_last_read(user_id, room_id)
{:ok,
socket
|> assign(:room_id, room_id)
|> assign(:last_read, last_read)
|> load_messages()}
end
def handle_event("mark_read", %{"message_id" => id}, socket) do
# Persist to database
Messages.mark_as_read(
socket.assigns.current_user.id,
socket.assigns.room_id,
id
)
{:noreply, assign(socket, last_read: id)}
end
Benefits:
- State synchronized across devices
- Survives disconnections and deployments
- Enables offline-first features
- Provides audit trail
Phoenix performs automatic form recovery: in case of disconnections, Phoenix collects form data and resubmits it on reconnection.
This mechanism works out of the box for most forms, but you may want to customize or test it for complex forms.
Example: Long Form
<.form for={@form} phx-change="validate" phx-submit="save">
<.input field={@form[:name]} label="Name" />
<.input field={@form[:email]} label="Email" />
<.input field={@form[:bio]} type="textarea" label="Bio" />
<.button>Save</.button>
</.form>
If the connection drops while filling out the form:
- Form data is preserved in the browser
- On reconnection, LiveView resubmits via
phx-change
- Your
validate event handler is called
- Form state is restored
See the Form Bindings documentation for customization options.
Deployment Strategies
Rolling Deployments
The standard approach for zero-downtime deployments:
Deploy new instances
Start new application instances with the updated code.
Health checks pass
Wait for health checks to confirm new instances are ready.
Route traffic
Load balancer starts routing new connections to new instances.
Graceful shutdown
Old instances continue handling existing connections.
Connection migration
As connections disconnect, they reconnect to new instances.
# config/runtime.exs
config :my_app, MyAppWeb.Endpoint,
# Allow time for connections to drain
drain_time: 30_000,
# Graceful shutdown timeout
shutdown_timeout: 60_000
Blue-Green Deployments
For critical applications requiring instant rollback:
- Blue environment: Current production
- Green environment: New version
- Switch: Update load balancer to point to green
- Verify: Monitor for issues
- Rollback: Switch back to blue if needed
Ensure your database schema is compatible with both versions during the switch.
Canary Deployments
Gradually roll out to a subset of users:
# Route 10% of traffic to new version
defmodule MyAppWeb.Router do
pipeline :browser do
plug :accepts, ["html"]
plug :canary_routing
end
defp canary_routing(conn, _opts) do
if :rand.uniform(100) <= 10 do
# Route to canary version
assign(conn, :version, :canary)
else
assign(conn, :version, :stable)
end
end
end
Handling Complex State
If you have complex state that cannot be immediately handled by the practices above, consider these strategies:
Persist to Redis/Database
For complex, temporary state:
def mount(_params, session, socket) do
session_id = session["session_id"]
# Try to restore state from Redis
state = case Redis.get("lv_state:#{session_id}") do
{:ok, nil} -> initial_state()
{:ok, data} -> Jason.decode!(data)
end
{:ok, assign(socket, state)}
end
def terminate(_reason, socket) do
# Persist state on disconnect
session_id = socket.assigns.session_id
Redis.setex(
"lv_state:#{session_id}",
3600, # 1 hour TTL
Jason.encode!(extract_state(socket))
)
end
Graceful Session Migration
For long-running sessions like games:
# In your endpoint
config :my_app, MyAppWeb.Endpoint,
# Don't immediately close old connections
drain_time: :infinity
# In your game LiveView
def mount(%{"game_id" => game_id}, _session, socket) do
game = Games.get_or_create(game_id)
# Subscribe to game state
Phoenix.PubSub.subscribe(MyApp.PubSub, "game:#{game_id}")
{:ok, assign(socket, game: game)}
end
# Deploy script
# 1. Start new servers
# 2. Wait for current games to finish
# 3. Shutdown old servers
State Versioning
Handle incompatible state changes:
def mount(params, session, socket) do
state = case session["state_version"] do
"2.0" -> load_state_v2(session)
"1.0" -> migrate_state_v1_to_v2(session)
_ -> initial_state()
end
{:ok, assign(socket, state)}
end
Health Checks and Monitoring
Implement health checks for your deployment pipeline:
# lib/my_app_web/controllers/health_controller.ex
defmodule MyAppWeb.HealthController do
use MyAppWeb, :controller
def index(conn, _params) do
checks = [
database: check_database(),
redis: check_redis(),
pubsub: check_pubsub()
]
if Enum.all?(checks, fn {_key, status} -> status == :ok end) do
json(conn, %{status: "healthy", checks: checks})
else
conn
|> put_status(:service_unavailable)
|> json(%{status: "unhealthy", checks: checks})
end
end
defp check_database do
case Ecto.Adapters.SQL.query(MyApp.Repo, "SELECT 1") do
{:ok, _} -> :ok
_ -> :error
end
end
end
Connection Draining
Gracefully handle existing connections:
# lib/my_app/application.ex
def start(_type, _args) do
children = [
MyApp.Repo,
MyAppWeb.Telemetry,
{Phoenix.PubSub, name: MyApp.PubSub},
MyAppWeb.Endpoint
]
# Handle SIGTERM gracefully
:os.set_signal(:sigterm, :handle)
opts = [strategy: :one_for_one, name: MyApp.Supervisor]
Supervisor.start_link(children, opts)
end
Testing Deployments
Test your deployment strategy:
defmodule MyAppWeb.DeploymentTest do
use MyAppWeb.ConnCase
import Phoenix.LiveViewTest
test "state persists across reconnection", %{conn: conn} do
{:ok, view, _html} = live(conn, "/dashboard?tab=settings")
# Simulate disconnection
send(view.pid, :disconnect)
# Reconnect
{:ok, view, _html} = live(conn, "/dashboard?tab=settings")
# Verify state is restored
assert has_element?(view, "#settings-tab.active")
end
test "form data recovers after disconnection", %{conn: conn} do
{:ok, view, _html} = live(conn, "/users/new")
# Fill form
view
|> form("#user-form", user: %{name: "John", email: "john@example.com"})
|> render_change()
# Simulate disconnection and reconnection
send(view.pid, :disconnect)
{:ok, view, _html} = live(conn, "/users/new")
# Form data should be preserved
assert view |> element("input[name='user[name]']") |> render() =~ "John"
end
end
Best Practices Summary
Keep state in URLs
Use query parameters for UI state like tabs, filters, and sorting.
Use the database
Persist important state that should survive across sessions.
Trust form recovery
Phoenix automatically recovers form data on reconnection.
Implement health checks
Ensure new instances are ready before routing traffic.
Test reconnection
Verify your application handles disconnections gracefully.
Monitor deployments
Track error rates and performance during rollouts.
Common Pitfalls
Storing too much state in memoryDon’t rely solely on socket assigns for critical state. Use URLs and database persistence.
Not testing disconnectionsAlways test how your application behaves when connections drop and reconnect.
Ignoring database migrationsEnsure your database schema is compatible during rolling deployments where both old and new code run simultaneously.
Summary
Following the practices above will:
- Make deployments smooth and transparent to users
- Improve your application’s overall robustness
- Provide better user experience with shareable URLs
- Enable cross-device synchronization
- Reduce the amount of state you need to manage in memory
Most importantly, these practices make your application better regardless of deployment concerns, bringing benefits like indexing, link sharing, and device synchronization.